"""
Copyright (c) 2009 James Lindstorff
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
   notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
   notice, this list of conditions and the following disclaimer in the
   documentation and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED.  IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
"""

import re
import time

from varnishmport import CLIS_OK
from varnishmport import VarnishProtocol


class VarnishCLI(object):
    """Implements CLI interface to Varnish management port as methods.
    The commands implemented are listed below:
    - help [command]
    - banner
    - ping [timestamp]
    - status
    - start
    - stop
    - stats
    - vcl.load <configname> <filename>
    - vcl.inline <configname> <quoted_VCLstring>
    - vcl.use <configname>
    - vcl.discard <configname>
    - vcl.list
    - vcl.show <configname>
    - param.show [-l] [<param>]
    - param.set <param> <value>
    - quit
    - purge.url <regexp>
    - purge.hash <regexp>
    - purge <field> <operator> <arg> [&& <field> <oper> <arg>]...
    - purge.list
    - debug.health
    """

    def __init__(self, host='localhost', port=6082, secret=None):
        """Initialize with host, port and secret of the Varnish management port
        to communicate with. Defaults are localhost port 6082 secret None.
        If Varnish is started with -S secret will need to contain a reference
        to a file corresponding to the one Varnish was started with."""
        self.__mport = VarnishProtocol(host, port, secret)
        self.__lastStatusCode = None
        self.__lastResponse = None
        self.__lastCommand = None


    def banner(self):
        """Print the welcome banner. Returns (statuscode, welcome)."""
        return self.send('banner')


    def health(self):
        """Dump backend health stuff for all backend probes running.
        Returns (statuscode, [(backend, health, average response)...])
        health will be either Healthy or Sick, avarage response is the
        average response from good probes. The health list will be
        empty is statuscode is not CLIS_OK or no probes has been defined
        in the VCL's.
        """
        healthlist = []
        (statuscode, healthtext) = self.send('debug.health')
        if CLIS_OK == statuscode:
            # Matching output like this
            # cli_out(cli, "Backend %s is %s\n",
            #       vt->backend->vcl_name, vt->backend->healthy ? "Healthy" : "Sick");
            # cli_out(cli, "Current states  good: %2u threshold: %2u window: %2u\n",
            #    vt->good, vt->probe.threshold, vt->probe.window);
            # cli_out(cli, "Average responsetime of good probes: %.6f\n", vt->avg);
            # cli_out(cli, "Oldest                                                    Newest\n");
            # cli_out(cli, "================================================================\n");
            patternBackend = re.compile('^Backend\s+(\S+)\s+is\s+(\S+)$')
            patternAverage = re.compile('^Average.+(\d+\.\d+)$')

            backend = None
            status = None
            average = 0.0
            for line in healthtext.splitlines(False):
                matchBackend = patternBackend.search(line)
                if not matchBackend is None:
                    backend = matchBackend.group(1)
                    status = matchBackend.group(2)
                    continue
                matchAverage = patternAverage.search(line)
                if not matchAverage is None:
                    average = float(matchAverage.group(1))
                    healthlist.append((backend, status, average))
                    continue
        return (statuscode, healthlist)


    def help(self, command=None):
        """Command/protocol help. Note that this is not help for this object
        but the help for Varnish CLI. If activated without a command
        a list of CLI commands are returned on the form (statuscode, commandlist)
        otherwise the command specific information is returned as
        (statuscode, command, description)."""
        if command is None:
            # Do a list of commands
            (statuscode, text) = self.send('help')
            if CLIS_OK == statuscode:
                commands = text.splitlines(False)
                return (statuscode, commands)
            else:
                return (statuscode, text)
        else:
            # Get information on a specific command.
            helpCommand = "help %s" % (command)
            (statuscode, text) = self.send(helpCommand)
            if CLIS_OK == statuscode:
                commandDescription = text.splitlines(False)
                commandText = commandDescription[0].strip()
                descriptionText  = commandDescription[1].strip()
                return (statuscode, commandText, descriptionText)
            else:
                return (statuscode, text, "Unknown command.")


    def paramSet(self, parameter, value):
        """Set parameter value.
        Returns (statuscode, statustext)."""
        paramSetCommand = "param.set %s %s" % (parameter, value)
        return self.send(paramSetCommand)


    def paramShow(self, parameter=None):
        """Varnish parameters and their values. If parameter is None a list
        of all parameters and their value are returned otherwise only the
        given parameter and it's value are returned. Result is returned as
        a tuple on the form: (statuscode, [(param, description, value),...])
        The list in the top level tuple will be empty if statuscode is not CLIS_OK.
        If the parameter is unknown the statuscode will be CLIS_PARAM.
        """
        parameters = []
        patternValueLine = re.compile('^(\S+)\s+(.+)$')

        if parameter is None:
            command = 'param.show -l'
        else:
            command = "param.show %s" % (parameter)
        (statuscode, text) = self.send(command)
        if CLIS_OK == statuscode:
            currentParameter = None
            currentDescription = ''
            currentValue = ''
            for statLine in text.splitlines(False):
                match = patternValueLine.search(statLine)
                if match is None:
                    currentDescription = currentDescription + statLine.lstrip(' ') + '\n'
                else:
                    if not currentParameter is None:
                        parameters.append((currentParameter, currentDescription, currentValue))
                    currentParameter = match.group(1)
                    currentDescription = ''
                    currentValue = match.group(2)
            # Remember the last one
            if not currentParameter is None:
                parameters.append((currentParameter, currentDescription, currentValue))
        return (statuscode, parameters)


    def ping(self, timestamp=None):
        """Keep connection alive. The timestamp if supplied should be number of
        seconds since epoch, int() is run on it before use. When not supplied
        time.time() as an integer is used as a timestamp. Returns a five tuple
        on the form (statuscode, text, timestamp, pongtime, duration). If statuscode
        is not CLIS_OK pongtime and duration will be None."""
        if timestamp is None:
            now = int(time.time())
        else:
            now = int(timestamp)
        pongtime = None
        duration = None

        pingCommand = "ping %d" % (now)
        (statuscode, text) = self.send(pingCommand)
        if CLIS_OK == statuscode:
            pongMatch = re.search('^PONG\s(\d+)\s1\.0$', text)
            if not pongMatch is None:
                pongtime = int(pongMatch.group(1))
                duration = pongtime - now
        return (statuscode, text, now, pongtime, duration)


    def purge(self, conditions):
        """Mark all objects where the conditions match obsolete.
        Conditions must be supplied as a string on the form:
        <field> <operator> <arg> [&& <field> <operator> <arg>]...
        Returns (statuscode, statustext)."""
        purgeCommand = "purge %s" % (conditions)
        return self.send(purgeCommand)
        pass


    def purgeHash(self, pattern):
        """Mark all objects where the hash string matches pattern obsolete.
        Returns (statuscode, statustext). Note that the parameter
        purge_hash (which is off by default) must be on for this call to succeed.
        """
        purgeCommand = "purge.hash %s" % (pattern)
        return self.send(purgeCommand)


    def purgeList(self):
        """List active purges.
        Returns a tuple on the form (statuscode, list of purges) where
        list of purges is [(pointer, timestamp, refcount, gone, test),...]."""
        purgelist = []

        # Pattern for matching
        #cli_out(cli, "%p %10.6f %5u%s\t%s\n", b, b->t0,
        #            bl == b ? b->refcount - 1 : b->refcount,
        #            b->flags & BAN_F_GONE ? "G" : " ", b->test);
        purgeListPattern = re.compile('^(\S+)\s+(\d+\.\d+)\s+(\d+)(.)\t(.+)?$')
        (statuscode, text) = self.send('purge.list')
        if CLIS_OK == statuscode:
            for purgeLine in text.splitlines(False):
                match = purgeListPattern.search(purgeLine)
                if not match is None:
                    pointer = match.group(1)
                    timestamp = match.group(2)
                    refcount = match.group(3)
                    gone = match.group(4)
                    test = match.group(5)
                    purgelist.append((pointer, timestamp, refcount, gone, test))
        return (statuscode, purgelist)


    def purgeUrl(self, pattern):
        """Mark all objects where the urls matches the pattern obsolete.
        Returns (statuscode, statustext). """
        purgeCommand = "purge.url %s" % (pattern)
        return self.send(purgeCommand)
        pass


    def quit(self):
        """Close connection to Varnish management port.
        Returns (statuscode, statustext)."""
        return self.send('quit')


    def start(self):
        """Start the Varnish cache process.
        Returns (statuscode, statustext) the statustext will be empty if
        the status code is CLIS_OK."""
        return self.send('start')


    def stats(self):
        """Get summary statistics.
        Returns a tuple with statuscode and a list of tuples 
        on the form [(statname, value)...], if statuscode is not CLIS_OK the
        list will be empty."""
        (statuscode, text) = self.send('stats')
        statistics = []
        if CLIS_OK == statuscode:
            pattern = re.compile('^\s*(\d+)\s+(.+)$')
            for statisticsLine in text.splitlines(False):
                match = pattern.search(statisticsLine)
                if not match is None:
                    statisticsValue = int(match.group(1))
                    statisticsName = match.group(2)
                    statistics.append((statisticsValue, statisticsName))
        return (statuscode, statistics)


    def status(self):
        """Check status of Varnish cache process.
        Returns (statuscode, process status)."""
        return self.send('status')


    def stop(self):
        """Stop the Varnish cache process.
        Returns (statuscode, process status)."""
        return self.send('stop')


    def vclDiscard(self, name):
        """Unload the named configuration (when possible).
        Returns (statuscode, statustext)."""
        discardCommand = "vcl.discard %s" % (name)
        return self.send(discardCommand)


    def vclInline(self, name, vcl):
        """Compile and load the VCL data under the name provided.
        Returns (statuscode, statustext)."""
        # We need to quote the vcl.
        quotedVcl = vcl.replace("\n", "\\n")
        quotedVcl = quotedVcl.replace("\t", "\\t")
        quotedVcl = quotedVcl.replace("\"", "\\\"")
        quotedVcl = quotedVcl.replace("\\\\", "\\\\\\\\")
        inlineCommand = "vcl.inline %s \"%s\"" % (name, quotedVcl)
        return self.send(inlineCommand)


    def vclList(self):
        """List all loaded configurations.
        Returns a tuple on the form (statuscode, [(state, busy, name)...])
        where the list will be empty unless statuscode is CLIS_OK."""
        vcls = []
        (statuscode, text) = self.send('vcl.list')
        if CLIS_OK == statuscode:
            # Match cli_out(cli, "%-10s %6u %s\n", flg, vcl->conf->busy, vcl->name);
            pattern = re.compile('^(\S+)\s+(\d+)\s+(\S+)$')
            for vclLine in text.splitlines(False):
                match = pattern.search(vclLine)
                if not match is None:
                    state = match.group(1)
                    busy = match.group(2)
                    name = match.group(3)
                    vcls.append((state, busy, name))
        return (statuscode, vcls)


    def vclLoad(self, name, filename):
        """Compile and load the VCL file under the name provided. Note that
        the filename is on the machine connected not nessarily the local
        filesystem. If the machine is remote and the vcl is local use vclInline.
        Returns (statuscode, statustext)."""
        loadCommand = "vcl.load %s %s" % (name, filename)
        return self.send(loadCommand)


    def vclShow(self, name):
        """Source code for the specified configuration.
        Returns (statuscode, source)."""
        showCommand = "vcl.show %s" % (name)
        return self.send(showCommand)


    def vclUse(self, name):
        """Switch to the named configuration immediately.
        Returns (statuscode, statustext)."""
        useCommand = "vcl.use %s" % (name)
        return self.send(useCommand)


    def send(self, command):
        """Send a command to the Varnish management port. This method can
        be used to send raw text commands. It's primarily for intended for
        internal use but can be used for direct interaction with the management
        port though using VarnishProtocol is a more direct way if only
        raw access is needed. Returns (statuscode, response)."""
        self.__lastCommand = command
        (statuscode, response) = self.__mport.send(command)
        self.__lastStatusCode = statuscode
        self.__lastResponse = response
        return (statuscode, response)

    statuscode = property(fget=lambda self: self.__lastStatusCode, doc="Last status code.")
    response = property(fget=lambda self: self.__lastResponse, doc="Last response text.")
    command = property(fget=lambda self: self.__lastCommand, doc="Last CLI command send.")

    # Start of helpers build on the above basics

    def cacheProcessRunning(self):
        """Check if the Varnish cache process is running.
        Returns True if the process is running.  The check is done using the
        method status."""
        (statuscode, text) = self.status();
        return ((CLIS_OK == statuscode) and text.endswith('running'))


    def helpDetail(self):
        """Get a dictionary containing all command and the detail for each
        of them. If it fails an empty distionary will be returned. Dictionary is
        on the form {command: (command_with_params, description), ...}."""
        commandDict = {}
        (statuscode, commands) = self.help()
        if CLIS_OK == statuscode:
            for command in commands:
                # Get command without the arguments which is the text until
                # either newline or space is encountered.
                firstSpace = command.find(' ')
                if -1 == firstSpace:
                    queryCommand = command
                else:
                    queryCommand = command[:firstSpace]
                (statuscode, commandtext, description) = self.help(queryCommand)
                if CLIS_OK == statuscode:
                    commandDict[queryCommand] = (commandtext, description)
        return commandDict


    def restart(self, wait=0.5):
        """Restart the Varnish cache process. The wait (default 0.5) is the number
        of seconds to wait between stopping the cache process and starting
        it again. Waiting is done with the time.sleep method. Returns tuple
        on the form (statuscode, statustext) statuscode will be CLIS_OK
        if restart was performed correctly."""
        if self.cacheProcessRunning():
            (statuscode, statustext) = self.stop()
            if not CLIS_OK == statuscode:
                return (statuscode, statustext)
        if 0.0 < wait:
            time.sleep(wait)
        return self.start()
